Skip to content

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 Array.to_list Sys.argv with
    | _ :: tail ->
        let past = ConfigFetcher.fetch Tori.Schema.origin
            |> Tori.Checks.post_config
        in
        let future = Tori.Parsers.Argument.interpret past tail
            |> Tori.Checks.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:

  1. a past is retrieved from the configuration parser
  2. a future is obtained by passing this past and the command line arguments to the argument parser
  3. the future’s exit status is passed to the standard library’s exit 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.

let origin : schema = {
    meta = {
        version = { major = 0; minor = 8; patch = 0; };
        help = {
            short = "<short help>";
            long = "<long help>";
        };
        error_level = Clear;
        status = 0;
        defaults = {
            paths = {
                configuration = Unix.getenv "HOME" ^ "/.config/tori/tori.conf";
            };
        };
    };
    input = {
        configuration = {
            main = {
                su_command = [ "su"; "-c" ];
                su_command_quoted = Default;
                interactive = true;
                simulate = false;
            };
        };
    };
    output = { main = ""; log = ""; };
    host = {
        os = Unknown;
        name = "Unknown Host";
    };
}

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:

let format_version (version : version) : string =
    "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:

let string_of_key key =
    match key with
        | SuCommand -> "su_command"
        | SuCommandQuoted -> "su_command_quoted"
        | Interactive -> "interactive"
        | Simulate -> "simulate"
        | Unknown -> "<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:

type configuration_key =
    | SuCommand
    | SuCommandQuoted
    | Interactive
    | Simulate
    | Unknown

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:

let scan_line (input: char list): token list =
    let rec to_tokens (chars: char list) (position: int) (tokens: token list) =
        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 scan (char_lists: char lists): token lists =
    let tokens = rmap (scan_line) char_lists $: [End] 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 ($) = (@@)
let ($:) list element = list @ [element]
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 identify : string =
    let os_release = String.split_on_char '\n' (File.read "/etc/os-release") in
    Utilities.Log.elog ~context:OS (String.concat "\n" os_release);

    let os_equals = List.find (String.starts_with ~prefix:"NAME=") os_release in
    match String.split_on_char '=' os_equals with
    | [ _; s ] ->
        String.trim @@ String.map (fun c -> if c = '"' then ' ' else c) s
    | _ -> "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:

type status = Exit of int | Unevaluated
type command = { name : string; arguments : string list; status : status }

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.

let run (command : Command.command) : Command.command =
    match Unix.fork () with
    | 0 -> Unix.execvp command.name (Array.of_list command.arguments)
    | pid -> (
        let _, status = Unix.waitpid [] pid in
        match status with
        | WSTOPPED n | WSIGNALED n | WEXITED n ->
            { command with status = Exit n }
      )

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:

let run_many (commands : Command.command list) : Command.command list =
    List.map run commands

See what I mean about conciseness? If we remove the type nerd fluff we get:

let run_many commands = List.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 elevate_wrapped (schema: schema) (command: string list): string list =
    let su_command = schema.input.configuration.main.su_command in
    match schema.input.configuration.main.su_command_quoted with
    | true|Default -> List.concat [ su_command; [(String.concat " " command)]; ]
    | false -> List.concat [ su_command; ["--"]; (command); ]

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:

type command = { interactive: string list; batch: string list }
type manager = { install: command; remove: command }
type manager_table = { apk: manager }

Which we can now use to create a table of supported package managers:

let table: manager_table = {
    apk = {
        install = {
            interactive = [ "apk"; "-i"; "add"; ];
            batch = [ "apk"; "--no-interactive"; "add"; ];
        };
        remove = {
            interactive = [ "apk"; "-i"; "del"; ];
            batch = [ "apk"; "--no-interactive"; "del"; ];
        }
    }
}

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.

let merge (schema : Schema.schema) (packages : string list) : Schema.schema =
    match packages with
    | [] -> (* do-nothing empty package list case suppressed for brevity *)
    | _ ->
        let su_command_line = schema.input.configuration.main.su_command in
        let su_command = Process.Su.head_of_su_command su_command_line in
        let su = Process.Su.elevate_wrapped
        let commands : Process.Command.command list =
            [
              {
                name = su_command;
                arguments = su schema $ manager.install.interactive @ packages;
                status = Unevaluated;
              };
              {
                name = su_command;
                arguments = su schema $ manager.remove.interactive @ packages;
                status = Unevaluated;
              };
            ]
        in

        let log_output =
            if simulate then
                "Would execute:\n" ^
                String.concat "\n" (Process.Command.format_many commands)
            else
                let ran =
                    if simulate then [] else Process.Fork.run_many commands in
                "Executed:\n" ^
                String.concat "\n" (Process.Command.format_many ran) in
        {
          schema with
          output = { schema.output with log = log_output; };
        }

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.


  1. August 19, 2025 – September 19, 2025