iganaq: OCaml
2025-09-20
As announced before, work is ongoing to decide on what language the future versions of tori will be implemented in. For this purpose, I’ve defined a minimal specification that I’m using to prototype and experiment with three different languages: OCaml, Haskell and Rust. The balance after all three are done will inform the final decision.
In other news, I’ve recently made arrangements to make tori the topic of my dissertation for the Systems Analysis undergraduate program at the São Paulo Federal Institute.
In this post, I’ll comment on the OCaml implementation, finished on May 17th, 2025. For the corresponding OCaml source code, you can refer to the main repository or any of its mirrors on Codeberg or GitHub.
Language
Type system
OCaml’s type system is one of its highlights. It is very expressive and at the same time concise, with the potential for very clean syntax with little to no type annotations at all due to type inference. As a type freak myself, I do prefer to explicitly type most things, because I like my errors to come up as early as possible. But it’s a feature.
What I particularly like about inference is not being able to leave out type declarations, it’s the compiler’s ability to consider what type something must be. It is particularly interesting when, because you have not typed something, you still have a clue as to what its type would be given the context, and the compiler is one step ahead of you and just says “this is typed as x
, but it should be y
”.
In this sense, there is something very “best of both worlds” about inference. Static types without inference will tell you what type something is, but just because someone told it before – helpful, but not as intelligent. Conversely, dynamic types will infer everything for you, but they can’t tell you what is being inferred, because they don’t know either.
Syntax
OCaml syntax is extremely clean, in fact I feel it’s one of the cleanest I have ever seen. This is a huge positive to me as it makes reading it easier, with no trying to understand which bracket matches what, with tooling or not.
Having its roots in the ML family of languages, it’s very different from C-style languages. This is a positive for me, as I feel it’s more readable, but adding another syntax style also adds more cognitive load in a world where most languages are C-style. It’s easy to call something “concise” and “readable” with bare-bones examples that are not far from the Hello, World stage. It’s a different matter entirely to write concise and readable code for a complex problem.
Coupled with inference, a clean syntax results in very succint code. Add the powerful type system and the ample scope of the language, which is functional first with imperative and object-oriented capabilities, and you have a very high degree of expressiveness.
Ecosystem
Aside from the experience of writing in the language itself, another crucial aspect for evaluating it for a project is the ecosystem, by which I mean everything around the language that supports it in several ways: documentation, tooling, governance, community.
The OCaml ecosystem is not the most lively of the three being evaluated. This is merely my impression, as it’s hard to compare the liveliness of ecosystems quantitatively. Say we went for the total number of packages in the main library repository: a lousy metric because each language has a different standard library that gives different reasons for libraries to exist or not.
While it is not as lively as the other two, it is by no means small or in a halt. It is alive, people are constantly chatting about it in the official forums and chat rooms, new versions of libraries and whole new libraries are announced all the time, and there is more than enough to solve a plethora of problems.
Libraries
According to the official opam website, there are 4563 packages available. The repository had 160 pull requests merged in the past 30 days.[1] I still think these are lousy metrics that would need more work to be useful, but they can give us an overall view other than my personal impressions.
For iganaq, no libraries were necessary, and that is something I very much wanted to evaluate: can it be done with the language’s standard library alone? The OCaml standard library is known for being very slim, so much so that the ecosystem includes more than one “third-party standard library” that you can install to expand and override the defaults.
This does seem to be changing, though. In recent versions of OCaml and the OCaml standard library, the trend seems to be making it more complete and capable with every release, rather than staying small.
A contentious topic, as mostly any topic around programming languages, but personally I do prefer bulkier, batteries-included standard libraries, so I can get more done with less dependencies.
Documentation
I find the documentation for the core language itself quite satisfying, but I can’t say the same about third-party libraries. Most of them will provide the output of odoc, the OCaml documentation compiler, which may or may not be as useful as documentation written by and for humans, with examples and explanations on usage, not just symbols. This is not entirely overlooked though.
OCaml has documentation for mostly anything the standard library provides. Take, for example, the docs for the Unix
module, which I consulted a lot while implementing iganaq. References to types that are not primitives are clickable and several descriptions are extensive. Working with these wasn’t an issue, and everything is available over the web, including extensive prose on the fundamentals of the language and its inner workings, with interesting highlights being Sherlodoc, the Hoogle of OCaml, and the near-cannon Real World OCaml book, written by Anil Madhavapeddy, an OCaml delegate.
Governance
My main criteria around governance is project independence from profit-motivated organizations, which is a motivation that may or may not prioritize the language and its community over their bottom line. One other red flag for me is the idea of a “benevolent dictator for life”, which I feel is a poor way to direct a project as versatile as a programming language, particularly given how large, vibrant and involved the communities that form around programming languages can become.
OCaml is known as an “academic” language, and indeed its history seems to have several branches coming from and going back into academia. But it seems to me its roots are more entwined with government, with a strong relationship to Inria, France’s “National Institute for Research in Digital Science and Technology”.
Despite that, its governance policy describes it as having an “Owner”, and goes on to say:
it is the community that actively contributes to the day-to-day maintenance of these initiatives, but the general strategic direction is drawn by the Owner
While this may sound autocratic at first, it continues:
It is the community’s role to guide the decisions of the Owner through active engagement, contributions, and discussions. To foster a healthy and growing community, the Owner will make the goals and decisions clear and public.
It is anticipated that the Projects themselves will be self-managing and will resolve issues within their communities, without recourse to the Owner. Where the Owner needs to become involved, he/she will act as arbitrator.
I think this governance model could be a problem with a problematic owner, but I’m not aware, as someone not too deeply involved, of any controversies involving the role.
Community
While niche, OCaml does have an active community. The official forums are where most search queries landed, rather than Stack Overflow, and I never stumbled upon rude and pedantic answers signaling an elitism culture, a major red flag for me when evaluating any software project.
Tooling
I think tooling is the only aspect of OCaml that I can’t say I’m happy about. This hinges on two main issues: an unintuitive build tool and the lack of a relocatable compiler. And I am happy to report that a solution to the second issue is on the way.
OCaml’s most used build tool is Dune, which uses S-expressions for its configuration language. I am not the biggest fan of S-expressions because I don’t think they nest well and end up with poor readability as complexity increases.
Dune gets the job done. One feature I miss in it is the ability to define arbitrary commands to eliminate the need for a Makefile or equivalent alongside it, but that is also not a major issue.
The problem with not having a relocatable compiler, which means the system expects the compiler to be at a specific path, preventing, for instance, multiple OCaml projects from reusing the same compiler, is that whenever you’d create a new environment to work on a new OCaml project, you’d have to recompile the OCaml compiler (!). That is not fast depending on your hardware, so it can slow you down.
This however is being worked on and from what it looks like it’s something with a very advanced solution already. With this solution on the way and the other issues being minor ones, while a bit annoying at times, OCaml tooling is not a deal-breaker.
Implementation
Now to the actual insides of what it was like to implement iganaq in OCaml.
Entry point
Another feature I appreciate having available in OCaml is pattern matching. The program’s entry point is exactly that: a pattern matching expression against the command line arguments:
let ( =
match to_list argv
let past = fetch origin
|> post_config
in
let future = interpret past tail
|> exit
in
exit future.meta.status
assert false
Here, I drop the first element in the argument array (the command name, tori
) and pass all others (tail
) to an argument parser along with the values from configuration stored on disk.
The argument parser will then return a future
from this, which is then used to set the exit status. I use this pattern a lot in the implementation: call the function input or some previous state the past
, work inside the function with a present
and construct a future
to be returned. The type of all three is usually a schema
, to be detailed below.
The last pattern matching case, [] -> assert false
is there only to make the matching exhaustive, since Sys.argv
will always at least contain the program name.
So by looking at the entry point above, we can see that everything that really happens is contained in three steps:
- a
past
is retrieved from the configuration parser - a
future
is obtained by passing thispast
and the command line arguments to the argument parser - the
future
’s exit status is passed to the standard library’sexit
function, terminating the program
Modules
For implementing the spec, I ended up with the following module structure:
lib
├── checks
│ └── checks.ml
├── parsers
│ ├── argument.ml
│ └── config
│ ├── fetcher.ml
│ ├── lexer.ml
│ └── parser.ml
├── schema
│ └── schema.ml
├── system
│ ├── file.ml
│ ├── os.ml
│ ├── package.ml
│ └── process
│ ├── command.ml
│ ├── fork.ml
│ ├── reader.ml
│ └── su.ml
├── types
│ └── structures.ml
└── utilities
├── aliases.ml
├── exceptions.ml
├── log.ml
└── text.ml
Schema
I like the Elm Architecture concept and I end up replicating some variation of it in a lot of things I do.
For this project, I wanted a data structure I could pass around to atomically represent the application state at any given point in time, instead of spreading state all over the place. I called this the “schema”:
Many functions in the implementation receive a schema as an argument (sometimes the only one), transform it and then also return a schema. This allows me to pass a schema to a sequence of functions through pipes and get an end-state of all their transformations as the result, greatly simplifying control flow.
Type definitions aside, the schema.ml
file contains some helper functions and this origin
schema, which is used as the starting point for application state, establishing the defaults.
For example, the following function will format a version from the schema into a string in the conventional v1.2.3
format:
"v" ^ string_of_int version.major ^
"." ^ string_of_int version.minor ^
"." ^ string_of_int version.patch
This one will take a configuration key
from the sum type configuration_key
and return a string, which was useful for logging purposes:
match key "su_command"
"su_command_quoted"
"interactive"
"simulate"
"<unknown key>"
I think these functions are great examples of how minimal and clean OCaml syntax can look like. See, for instance, the type definition for configuration_key
:
This is not only very readable and concise, but by leveraging a sum type I get free exhaustion checks anywhere I use this when pattern matching. And by encapsulating most (all?) state in a single data structure with a tiny memory budget and several specialized inner types, nearly everything that happens changes only within the constraints of the schema.
Parsers
I implemented two simple parsers for this project: an argument parser and a configuration parser for a subset of the ini
configuration format.
The configuration lexer and parser
The Config
submodule in the Parsers
module provides a fetch
function that starts from the origin
schema and passes the configuration path defined in it to the other modules main functions: Lexer.scan
, Lexer.concat
, Parser.parse
and finally Parser.apply
.
The lexer and parser are your typical run-of-the-mill textbook lexer and parser. One distinction is that they know what keys to expect not from their own code, but from the types defined as possible keys in the schema. If the schema changes, type checking will therefore immediately break in the lexer, which I think is an excellent way to wire things up.
The configuration lexer
What the lexer
is concerned with is matching string literals found in a text file with these key types it knows from the schema and the possible tokens that can encapsulate their values. Because we are working with a subset of ini
(which in itself is already very simple), it finds a middle position from the =
character and a final position from a newline and there you go, to the left are possible keys and to the right are possible values:
if position == length chars then tokens
else let token, next_position = lex chars position in
to_tokens chars next_position $ token :: tokens
in
reverse $ to_tokens input 0 [
(* char_lists has the contents of the configuration file *)
let tokens = rmap char_lists $: in
elog ~context:Parsing $ string_of_tokens tokens;
tokens
Note I defined some custom operators above, which also shows something OCaml can do which I like:
let =
list @
The configuration parser
Once the lexer has turned a text file into a list of tokens, the configuration fetcher will pipe this list of tokens to the configuration parser, which is responsible for finally assembling a schema that reflects what was defined in the configuration.
This closes the circle for the fetcher, which works on the outer boundary, receiving and returning a schema.
Other than this reliance on the schema as the final state, the parser implementation is pretty undifferentiated and uninteresting. In fact, it’s likely fair to say a significant portion of it is logging code, and it would look rather tiny if there weren’t any logging or if logging was abstracted away.
The argument parser
The argument parser takes the schema that resulted from the configuration parser’s work and a list of strings (the command line arguments). It looks at the arguments, calls the corresponding functions for the commands, such as check
, version
or help
, or prints an error message in case the command matches none of its known strings.
I left a note in a comment here. The flow could be simplified by passing more data back in the schema, returning a data structure containing orders and outputs to be executed and printed in a later step, by other functions, rather than using what was supposed to be a parser as a glorified switch that calls all sorts of side effects into execution.
This however was a tiny implementation meant simply to evaluate OCaml itself, so I left that as possible improvement for later (as I did in several other spots, if you are curious enough to check the source code).
System calls
The System
module is what the argument parser calls to execute most of the commands it was given in the command line.
To fulfill its mission, it only needs to be able to read file contents, determine what is the operating system it’s running on, and, more elaborately, execute commands and interface with the system package manager to list, install and uninstall packages.
These goals are fulfilled by the submodules File
, OS
, Process
and Package
.
The File
module is so boilerplatey I won’t even mention its code. The OS
module is just a bit less tiny:
let os_release = split_on_char '\n' in
~context:OS ;
elog
let os_equals = find os_release in
match split_on_char '=' os_equals
@@ map s
trim "Unknown"
It uses the File
module’s functions to read the contents of the /etc/os-release
file. It then does some silly string manipulation to strip quotes and get a clean OS name instead of double-quoted text.
Process
Certainly Process
is the most intricate of these four modest submodules. It’s responsible for executing commands available in the underlying Unix descendant tori is running on, bridging a realm of more narrowly-defined interactions and transformations to another of heavy side effects — not that I strived to keep the former realm very pure.
Process has four inner modules I’ll spare you from calling subsubmodules:
Command
Defines types to abstract the concept of a command, including its arguments, execution status and exit code:
It also has a “to string” utility function for logging purposes.
Fork
A run-of-the-mill function mostly just wrapping the standard library’s Unix.fork
function for running a command as defined in the previous Command
module.
match fork ( execvp command.name
It returns the same command data, but with updated execution status and exit code. Its companion, run_many
simply applies a map function to do the exact same thing but across two lists of commands:
map run commands
See what I mean about conciseness? If we remove the type nerd fluff we get:
map run commands
Makes you wonder if that function existing was even necessary.
Reader
Reader
is certainly the most undifferentiated and boring of all the modules. It’s basically a copy of How To Read a Buffer and just repeats the old routine of reading data until the end of a stream.
Su
Finally, Su
, which is not about adding a subtle touch of salt and vinegar to cooked rice, rather, it’s about how to determine the available way to elevate privileges to superuser.
If a value for the su_command
configuration key is not present, it must default to su -c
. Otherwise, it must use whatever was provided:
let su_command = schema.input.configuration.main.su_command in
match schema.input.configuration.main.su_command_quoted concat
concat
If the value for su_command
was never set, it will contain the default value as set in the origin
schema, which will never have been overwritten by the configuration parser. Because of this, the Su
module does not need to concern itself with or even know what this default is.
What it does concern itself with is whether or not the configuration establishes that the command must be quoted, which is true for su -c
but not true for sudo
or doas
. So it simply constructs a string list with a quoted or unquoted command and returns that.
Package
Another submodule that’s a bit more involved than many in the System
module is the Package
submodule. It creates an abstraction layer between tori and the package manager running in the current operating system.
For the purposes of the iganaq experiment, this OS is Alpine Linux.
To abstract away a package manager, we’ll need some types:
Which we can now use to create a table of supported package managers:
let table: manager_table =
On this foundation, we can write a function that will ask the system’s package manager for a list of manually installed packages and compare it to what state the current schema has, conciliating them by adding/removing packages until both lists match.
Remember the intention is not to provide actually useful functionality, rather, it’s to learn and demonstrate how both tasks — installing and uninstalling a system package — can be accomplished in each programming language.
match packages (* do-nothing empty package list case suppressed for brevity *)
let su_command_line = schema.input.configuration.main.su_command in
let su_command = head_of_su_command su_command_line in
let su = elevate_wrapped
in
let log_output =
if simulate then
"Would execute:\n" ^
"\n"
concat else
let ran =
if simulate then [ else run_many commands in
"Executed:\n" ^
"\n" in
concat
What I like about this is that commands can’t really be executed here without getting captured into the ran
contents, which is a pattern I’d like to move further into the Process
module. It also provides a way to simply simulate rather than execute commands, which is useful when running some tests.
What I don’t like about this are the several su
, su_command
, su_command_line
, head_of_su_command
declarations, which I frankly find confusing writing about this a few months later, so there’s definitely some room for improvement here.
As you can see in the code above, the Package
module actually constructs a sequence of commands (install then uninstall, as was the goal set out by the spec) and then passes them to the Process
module to handle actual execution.
I tried to place the explanation of modules and submodules in the order from least to most dependent so mostly everything is already explained by the time it’s covered, and this is where most of it comes together: with the Package
module making use of most of the other code.
Testing
For testing that the implementation fulfills the spec, I also needed to also write some tests.
Dune has this interesting feature called “Cram Tests”, which are a way to define integration tests running from a shell and executing your compiled binaries. That is something I hadn’t known to exist in any any other build tool I tried.
Here’s an example of a cram test that executes tori os
and checks if the output contains the actual OS
B2.3. os -> MUST print the os name
$ os_name=$(cat /etc/os-release | grep '^NAME=' | cut -d= -f 2 | sed 's/"//g')
$ tori_os=$(tori os)
$ test -n "$os_name"
$ test -n "$tori_os"
$ test "$os_name" = "$tori_os"
I used this feature extensively to verify that the implementation was doing as the spec states.
Conclusion
It probably shows that I enjoy writing OCaml, so it’s likely the language I am the most biased towards among the three candidates. Creating this implementation and refactoring it until I was satisfied only reinforced that.
The only thing I think one can hold against OCaml is that it’s not a highly popular language with a vibrant ecosystem. And that is not something under the full control of the OCaml project. But it can give you pause if you consider questions such as “will this choice make it less likely that others will contribute to the project?” or “How hard does this choice make for others to compile the project themselves?”
But as the first one, it’s still early to tell. So we’ll see in the next implementations.
As before, you can reach out with your comments through IRC or Matrix rooms, Mastodon or privately via email. You can also follow development through the code repository and its mirrors on Codeberg and GitHub.
-
August 19, 2025 – September 19, 2025 ↩