Blog

The OCaml Planet RSS

Articles and videos contributed by both experts, companies and passionate developers from the OCaml community. From in-depth technical articles, project highlights, community news, or insights into Open Source projects, the OCaml Planet RSS feed aggregator has something for everyone.

Want your Blog Posts or Videos to Show Here?

To contribute a blog post, or add your RSS feed, check out the Contributing Guide on GitHub.

k7: Keeping CLI Animations in Sync with Your Code

I have always been a fan of asciinema and of Charm's VHS. asciinema records a terminal while you use it, and VHS types a script of commands into a terminal and films the result. I wanted their animations in the READMEs of my projects, but I always forgot the syntax of VHS's tape files, and I always mistyped a command halfway through an asciinema recording (and then started again). I also always forgot to re-run those recordings when my API changed, and they got out of sync quickly. Fortunately, most of my projects already check their command-line output with cram tests, transcripts of shell commands and of what they print, which dune, OCaml's build system, runs as tests. Making those tests produce animated images was a short stretch, and then, why stop there? The result is k7 (how the French write cassette, if you say it out loud), a command-line tool that records any program, whatever its language, as an SVG image animated with CSS alone. Here is ls, and sl, which I regularly enjoy mistyping. Each took one command, such as k7 rec -o sl.svg -- sl, which generates an animation you can embed in any HTML page (and in your projects' READMEs). ls, as I meant to type it. sl, as I actually typed it. Animations that dune checks I was not the first to draw a terminal as an animated SVG. termtosvg (now archived) and svg-term-cli do it too. What k7 adds, I think, is that it both writes and plays dune's cram tests. A cram test is a text file that reads like a terminal session. Each command is indented and starts with $ , the output it should print sits below it, and any other line is a comment: Print a greeting: $ printf 'hello\n' hello dune test runs the commands of each test in a new empty directory and compares what they print with the file. When the output changes, dune shows the difference, and dune promote writes the new output into the file. k7 fits into that loop at both ends. k7 rec -o hello.t -- printf 'hello\n' writes the test above (without the comment). In the other direction, k7 play types the commands of a cram test into bash and draws what happens. The image at the top of k7's README comes from doc/demo.t, drawn by k7 play -o doc/demo.svg doc/demo.t. Dune runs the same file as a test, so the test fails exactly when the image needs drawing again, and drawing it again takes one command. doc/demo.t from k7's repository, typed into bash by k7 play. The same screen as VHS, every time k7 rec runs a program on a pseudo-terminal and feeds what it prints to a terminal emulator, matrix, from Thibaut Mattio's mosaic project at Invariant Systems. I wanted k7 to draw the same screens as VHS, which runs xterm.js in a browser, so I fixed the places where matrix and xterm.js disagreed and sent the fixes upstream. Each time the program changes the screen, k7 keeps a copy of it, at most 50 times a second (as in VHS). Each distinct line of text is defined once, each frame refers to the lines it shows, and a single CSS @keyframes rule slides the strip of frames, jumping to each one at the time the program drew it. This keeps the file small when most lines stay put: the image of ls above takes 4 KB. A train crossing the screen is the worst case, and the image of sl takes 356 KB. The GIF that VHS made of the same sl, on a terminal of the same size, takes 2.1 MB. VHS drives a headless browser and encodes the frames with ffmpeg, and on my laptop it took 53 seconds, against 8 for k7 rec, about 7 of which were sl itself. A program like sl has no cram test, since its output is an animation rather than text. For those, k7 rec -o sl.cast -- sl keeps the recording itself, in asciinema's asciicast v3 format, and k7 play draws it, or any asciinema recording, without running anything. Recording is not deterministic, but drawing is. Record sl twice and the two recordings differ, because the program never prints at quite the same times. Draw one recording twice and you get the same file, byte for byte. So dune can check a committed image against the recording beside it: (rule (target sl.generated.svg) (action (run k7 play -o %{target} %{dep:sl.cast}))) (rule (alias runtest) (action (diff sl.svg sl.generated.svg))) A tape syntax the compiler remembers That leaves the VHS tape syntax I kept forgetting. k7 plays .tape files too, and in OCaml, the K7 library builds tapes as values instead, so the compiler remembers the syntax for me: let tape = K7.tape ~settings:K7.[ Font_size 18; Width 1000; Height 560 ] ~outputs:[ "demo.gif" ] K7.[ type' "dune test\n"; wait Prompt; sleep (Ms 2000) ] K7.tape checks the tape against the rules of VHS's parser as soon as it is built, theme name included, where VHS only notices a misspelt theme when it runs the tape. Its tests hold it to vhs validate on 3000 random tape lines, as I held my Tailwind rewrite to Tailwind itself. Trying it k7 0.9.0 is in my Homebrew tap, for macOS and Linux, and on opam for OCaml 5.4 and later: $ brew install samoht/tap/k7 $ opam install k7 A few edge cases are not supported (the README lists them), but for most uses it seems to work fine. If k7 draws something differently from VHS or asciinema, please open an issue with the recording (k7 rec -o bug.cast -- yourprogram makes one).

26 Oct 2026

Thomas Gazagnaire

Read Article
An Overview of OCaml Release Readiness: How Does the OCaml Ecosystem Stay Up-to-Date with the Compiler?

What is the OCaml ecosystem doing to make upgrading to the latest version of the language seamless? When the OCaml compiler receives a new update, it has the potential to have wide-ranging effects to the ecosystem of tools that surround it. In the past, users have hesitated to move forward with the latest release due to proper tooling support lagging behind. To improve the user experience and make transitions smoother, OCaml developers and maintainers agreed to work together to ensure that the core tools were always ready near release time. This effort is referred to as ‘OCaml Release Readiness’. It is a complex task spread across many actors in the ecosystem and supervised by Florian Angeletti (Inria) and Kate Deplaix (OCSF). In this post, we will give you a behind-the-scenes look at the work that goes into getting the OCaml ecosystem ‘release ready’. What are the ‘Core Tools’? There is no definitive rule for which tools are part of the release readiness effort, but, as a general guideline, it includes the kind of tools that provide key support in projects. If an update is incompatible with a core tool, it will be too disruptive for the user to migrate to the latest compiler version. Let’s look at the tools that are currently part of the release readiness cycle and what the maintainers do to get them up-to-date with new releases. Ppxlib Ppxlib is a library for building pre-processors in OCaml. Pre-processors are called at compile time and alter the program before compilation. They can perform a whole host of helpful functions, such as including a file, generating boilerplate, or extending the language. More or less half of the OCaml ecosystem depends (transitively) on Ppxlib. Internally, ppxlib has a copy of every abstract syntax tree (AST) of every supported compiler. Currently, that is every compiler version from 4.08 to 5.5. This is necessary because ppxlib has to migrate the ASTs of every user to a common AST (5.2 at the time of writing) to perform the pre-processing, and then migrate back to the AST of the user for the rest of the compilation process. For each new OCaml release, a new AST corresponding to the latest version is added to the migration chain, and transformations are written to and from that AST. If this AST includes new syntactic features or new representations of existing features, then the complexity of the task grows. The maintainers need to write encodings that preserve the new features and representations, particularly in the migrations to and from older ASTs. New Ppxlib releases are prepared by Nathan Rebours, via the OCaml Software Foundation (OCSF) and OCamlPro, and by Patrick Ferris at Tarides. Merlin Merlin is an editor service for OCaml that supports advanced IDE features. It is an analysis tool that provides semantic information about OCaml code, communicated to editors via a protocol such as LSP. A cornerstone of its architecture is the ability to recover from parsing and typing errors, so it can keep providing useful information on lines below the error rather than stopping. Achieving this requires patching large parts of the compiler's parser and typechecker in non-trivial ways, which makes upgrading Merlin's vendored parser and typer to each new compiler version a complex, time-consuming task. Several ongoing upstreaming efforts should make future updates much easier, like type-recovery which is scheduled to be upstreamed in the 5.6 version of the compiler, and the open proposal to upstream Merlin's new -short-paths implementation directly into the compiler as well, removing a long-standing source of drift between the two. The release readiness work for Merlin is undertaken by a team at Tarides. OCaml-LSP OCaml-LSP is the LSP frontend for Merlin. It has only minor dependencies on the compiler, so once Merlin's upgrade to a new OCaml version is done, upgrading OCaml-LSP is usually straightforward. The release readiness work for OCaml-LSP is done at Tarides. Dune Dune is a composable build system for OCaml. Its support for both monorepos and multi-package repositories has made it popular among OCaml developers. Its main branch tracks the latest compiler versions, and because it sits so early in the dependency graph, failures are quick to surface. In practice, the problems that come up are about compiling Dune itself with a newer compiler and not about the projects it builds. Strictly speaking this isn't even required, since a Dune binary built with an older compiler can still build projects with a newer one. But opam constrains a switch to a single compiler version for all packages in it, so in practice Dune has to stay compatible with the latest compiler releases. The release readiness work for Dune is done by a team at Tarides. OCamlFormat OCamlFormat parses and pretty-prints OCaml code. We need to update it every time new syntax is added to the language, which surprisingly happens in almost every OCaml minor release. OCamlFormat vendors two versions of the OCaml parser, so upgrading requires backporting patches with Git and resolving conflicts. We then update and test the printing code. The first parser is used purely for verification: OCamlformat parses the code before and after formatting, compares the two ASTs, and refuses to format if they diverge. The second parser is extended to keep more information about the concrete syntax. For example, these two snippets would have the same AST with OCaml's parser: [ 1; 2 ] and 1 :: 2 :: []. They are encoded differently with our extended parser. The release readiness work for OCamlFormat is done at Tarides. Odoc Odoc reads compiler-produced artifacts containing the abstract syntax tree of compilation units in order to generate module documentation. Since the type of those ASTs evolves between OCaml versions, odoc defines its own internal AST to be independent of the compiler version. Odoc uses cppo to adapt how the compiler's AST is loaded into odoc's internal representation at compile time. When a new OCaml version introduces changes to the typedtree, odoc fails to compile until it's adapted. At minimum, this means updating the loading logic to handle the new types while staying compatible with older versions via cppo conditionals. Beyond that, odoc's internal AST may need to be extended to represent new features, and add the logic to support rendering the changes in the generated API documentation when applicable. The release readiness work on odoc is done at Tarides. Stay in Touch Did you know how much work goes on behind-the-scenes to keep the OCaml ecosystem’s tools up-to-date with new compiler versions? Connect with us on Bluesky, Mastodon, Threads, and LinkedIn or sign up for our mailing list to stay updated on our latest projects. We look forward to hearing from you!

07 Oct 2026

Tarides

Read Article
Rewriting Tailwind CSS in OCaml: Is It (Pixel-)Correct?

I really like Tailwind CSS. Keeping styles next to the HTML makes it much easier to keep the two in sync. I can see which styles a component uses without tracing selectors across files, and removing the component doesn't leave me wondering which CSS rules are still needed elsewhere. In my projects, though, Tailwind also brought a Node.js toolchain into the build. I use OCaml for most of my software projects, including the code that generates HTML for web applications (like this blog!), so keeping a second build loop in sync was awkward. I wanted the same convenience within OCaml: a component could carry its Tailwind styles with it, and the build could generate both HTML and CSS together. That library became tw, which, as of its 1.1.0 release, builds whole Tailwind v4 projects, with their themes and plugins, and without Node.js. It is a drop-in replacement for the Tailwind CLI, whatever language your project is written in: $ brew install samoht/tap/tw # or: opam install tw $ tw -i src/app.css -o dist/app.css But how could I tell whether it was a faithful replacement? Different CSS can produce the same page, and a stylesheet that looks plausible can still move a button or break a hover effect. This post describes the checks I built to answer that question. Each of them ended up trusting something it should not, and in the end I had to let the browser decide. Comparing the CSS Tailwind itself gave me the first oracle: an implementation that supplies the expected output. I fed the same classes to Tailwind and tw, compiled both, and compared the resulting CSS. Tailwind's own utility and variant fixtures provided the first inputs, followed by whole-project stylesheets that made the features interact. Each disagreement gave me something small to investigate. At first I aimed for identical bytes. That caught details a visual inspection would miss: a selector escaped incorrectly, a missing variable, a slightly different fractional width. It also turned a harmless change in whitespace or colour spelling into a failure. Once I started optimising the output, two compilers producing the same file was no longer the result I wanted. I wanted to allow different CSS that did the same job. So I needed a CSS-aware comparison. That became cascade, which I wrote about in July: it parses both files, normalises equivalent spellings, and reports the selectors and declarations that differ. With differences reported by rule, porting became much more pleasant: a change in padding no longer meant reading a line of several thousand characters. Who checks the checker? There is a problem with writing both the compiler and its checker. tw and cascade share CSS machinery: tw prints its output through cascade, and cascade also minifies it. If both mishandle the same construct, their agreement hides the mistake. For example, here is one HTML file and two possible stylesheets. Are they equivalent? Careful: your answer could have a big impact on your next software project! <style> .advice { position: relative; } .advice > .ocaml { position: absolute; inset: 0; background: white; } </style> <link rel="stylesheet" href="a.css"> <p class="advice"> <span>You should use Rust</span> <span class="ocaml">You should use OCaml</span> </p> One HTML page. Change the stylesheet link to b.css to compare. /* a.css */ .ocaml { all: unset; } .ocaml { visibility: hidden; } /* b.css: just swap the two rules. */ .ocaml { visibility: hidden; } .ocaml { all: unset; } Two candidate stylesheets. The reset and visibility rules trade places. The two rules don't name any of the same properties, so a tool that looks at each property separately could swap them. But all: unset also resets visibility, to its inherited value, visible here. With a.css the span is hidden and the reader sees Rust. With b.css it is visible and covers Rust. The positioning rule has higher specificity, so the reset doesn't move the span; it only changes whether you can see it. a.css You should use Rust b.css You should use OCaml The same HTML and declarations, with the reset applied in a different order. cascade reports the change to visibility: $ cascade diff --diff=canonical a.css b.css CSS: 54 chars vs 54 chars (0.0% diff) Changes: 1 modified rule --- a.css +++ b.css └─ .ocaml - visibility: hidden Catching this kind of mistake mattered more than usual, because since last year a mix of LLMs, some in the cloud and some running locally, has written much of the code in both tools, while I reviewed the changes and decided what to build next. That was partly an experiment in how to drive these models towards software I would trust, and it only works if the tests decide what gets accepted. But a model can change a test and the code it checks at the same time, and I cannot use cascade to check that cascade is correct. I needed a check that shares no code with either tool, and that neither I nor the models could change. Asking the browser I turned to headless Chrome. My first harness loaded a page under each stylesheet, read every element's computed style through getComputedStyle, and compared the values. This had two problems. First, computed values can be written in many equivalent ways, so the harness used cascade's own value comparator to decide which differences were real. The checker depended on the code it was supposed to check. Second, computed styles are not what users see. cascade minifies background:none to background:0 0, one byte shorter and painting exactly the same, yet getComputedStyle reports background-position as 0% 0% for one and 0px 0px for the other. So the harness now compares pixels. It renders the page under each stylesheet, at every viewport width the stylesheets' media queries mention and in every interaction state they use (:hover, :focus, and so on), and compares the screenshots. It reads computed styles only where pixels differ, to find which property is responsible. The same check is available from the command line: $ cascade diff --browser --html page.html a.css b.css Browser: 153.0; viewports: 1024x768; states: none Renders that differ: 1 1024x768 none: 149x13 pixels differ at (142,18) Computed values the elements under those pixels disagree on: body>p.advice:nth-child(1)>span.ocaml:nth-child(2) visibility: hidden -> visible A test only covers the documents and states it renders, so the example needs both spans, just as a hover rule needs a hover test. Some properties paint nothing at all: a cursor, or the timing of a transition. For those, the CSS comparison is still the only check, and that is why I keep both. Testing the diff itself With an independent reference, I could then test cascade's comparison directly, using mutation testing. The harness takes real stylesheets and breaks them mechanically: it drops a declaration, drops a rule, swaps two neighbouring declarations or rules, or splits a rule in two. Some of these mutants change the page, like our reset and visibility swap. Others are harmless, like removing a declaration that a later one overrides. Every time cascade says a mutant is equivalent to the original, Chrome renders both. If the pixels differ, cascade has missed a change, and that is a bug. cascade's verdict is only used to choose which pairs to render, so it can make the test slower but never make it pass. Several fixes in cascade 1.2.0 are cases where Chrome disagreed with its output. Many others come from a single pattern: parts of the minifier walked the stylesheet with their own match and a catch-all case, so an unfamiliar statement was silently skipped. If you used 1.1.0 to minify a page, regenerate it with 1.2.1 and diff the two. The whole of tailwindcss.com The largest test is the Tailwind website itself, which uses far more class combinations than any fixture. Every class it uses is compiled by both tools and rendered on its own element, inside wrappers that make the group-* and peer-* variants match. On a full run with tw 1.1.0 and cascade 1.2.1, cascade found no difference between the two stylesheets, and Chrome agreed at every viewport width and in every interaction state. tw's minified output was also slightly smaller than Tailwind's. That run still has limits. A variant that tests an ancestor's attribute, such as group-data-[checked]:, matches on neither side, so it is compared but not really exercised. And a few classes on the site are documentation placeholders such as blur-[<value>], for which Tailwind emits CSS that no browser accepts and tw emits nothing. Both pages render the same, so I count that as parity. Using it on your project tw reads the same CSS entrypoint as the tailwindcss CLI, with @theme, @source, custom utilities and variants, and the typography and forms plugins. It does not run JavaScript, so a project still using tailwind.config.js needs to move that configuration into its CSS entrypoint first. To check tw against Tailwind on your own project, add --diff. It compiles the project with both tools and explains the differences with cascade; with --html, it also compares the rendering of one of your pages in headless Chrome. This needs Tailwind 4.3.3 installed locally, but the normal build does not need Node.js at all. $ tw -i src/app.css --diff --html public/index.html cascade works on CSS from any other tool too, for instance to check that a minifier did not change your page: $ brew install samoht/tap/cascade # or: opam install cascade $ cascade diff --diff=canonical input.css output.css $ cascade diff --browser --html page.html input.css output.css In OCaml, I skip the scanning step altogether, as each component carries its own styles: open Tw_html let card ~title ~body = article ~tw:Tw.[ flex; flex_col; gap 4; p 6; rounded_lg ] [ h2 ~tw:Tw.[ text_xl; font_semibold ] [ txt title ]; p [ txt body ] ] Reusing card brings its CSS along, without a source scanner or a safelist. The April post shows the full workflow. Getting your feedback There are probably still bugs that none of these tests reach. If tw --diff reports a difference on your project, it is a bug in either the compiler or the comparison, and I would like to hear about it: a small reproducer on the tw issue tracker or by email is perfect. Reviews of either codebase are very welcome too. Try it, and tell me what breaks. Tailwind Labs' implementation, documentation and tests have been essential to this work, and tw depends on the framework they continue to develop. If you use Tailwind, through either compiler, please support the team by sponsoring them or buying a Tailwind Plus licence.

23 Sep 2026

Thomas Gazagnaire

Read Article
.plan-26-36: Ground control to major MODIS

Tessera v2 beta2 smooths space at the cost of time, new Pembroke Associate Scholars, Scrutineer's git remediation workflow, paying the Windows tax, and retiring AI disclosure metadata.

06 Sep 2026

Anil Madhavapeddy's Blog

Read Article
Security scanning my own code with Scrutineer and local coding models

Deploying Alpha-Omega's Scrutineer on a VM, driving its scans with a local GLM 5.3 on my Mac Studio, and thinking through practical workflows

31 Aug 2026

Anil Madhavapeddy's Blog

Read Article
.plan-26-34: Am I human or am I antibotty

TESSERA v2 beta embeddings are in the TZE explorer, security embargoes being outrun by agents, opam packages now need humans behind them, prometheus and eio releases

23 Aug 2026

Anil Madhavapeddy's Blog

Read Article
Updating to the OxCaml 5.2.0-minus39 opam packaging

How the OxCaml overlay's guard packages keep incompatible releases out, and how to contribute to it with your own packages.

08 Aug 2026

Anil Madhavapeddy's Blog

Read Article
Reusing Buffers in Multicore OCaml

OCaml is a pragmatic functional language: type-level tricks for safe code, careful buffer allocation for fast code. A scratch buffer in ocaml-wire raced under OCaml 5 domains; memtrace and ThreadSanitizer show both sides.

24 Jul 2026

Thomas Gazagnaire

Read Article